SIGSEGV core 排查:区分真实崩溃与 kill 自杀
多线程服务留下一个 SIGSEGV core,gdb 打开后崩溃线程停在 pthread_cond_wait 或其他等待函数里——这种栈通常不是真实的访存错误现场。本文介绍一套判别方法:先用 gdb 的 $_siginfo 判断信号来源,再决定是否信任崩溃线程的栈,最后给出定位调用方与还原触发场景的步骤。方法适用于任何 Linux 多线程程序的 core 分析。
现象
一台 ARM64 设备上的多线程服务(约 80 个线程)死亡并留下 core。gdb 打开后,崩溃线程是主线程,栈顶停在自己栈对象的条件变量等待里:
#0 __pthread_cond_wait_common
#1 std::condition_variable::wait
#2 NodeBase::Join (this=...) at node_base.hpp:259
#3 main
Program terminated with signal SIGSEGV 为什么这个栈可疑
等待中的线程,其栈帧和等待所用的 mutex、条件变量在等待期间始终有效且被映射,在等待点本身产生访存错误的可能性极低。遇到这种 core,应先怀疑信号是被送进来的,而不是这个位置的访存错误——直接顺着 bt 去查 NodeBase::Join 的代码会被带偏。
第一步:用 $_siginfo 判断信号来源
(gdb) p $_siginfo
{si_signo = 11, si_errno = 0, si_code = 0,
_kill = {si_pid = 1161, si_uid = 0}, _sigfault = {si_addr = 0x489}, ...} si_code 是判别依据,取值与含义如下(数值来自 Linux 内核头文件 asm-generic/siginfo.h,语义见 sigaction(2)):
| si_code | 值 | 含义 | 关键字段 |
|---|---|---|---|
| SI_USER | 0 | kill() / raise() 发送 | si_pid = 发送者 PID |
| SI_TKILL | -6 | tgkill() / pthread_kill() 发送 | si_pid = 发送者 PID |
| SEGV_MAPERR | 1 | 访存错误:地址未映射 | si_addr = 出错地址 |
| SEGV_ACCERR | 2 | 访存错误:权限不符 | si_addr = 出错地址 |
本例 si_code = 0(SI_USER),si_pid = 1161 恰好等于进程自身 PID(core 文件名里也带着 1161)——即进程内某个线程执行了 kill(getpid(), 11),把 SIGSEGV 发给了自己的进程。
顺带解释 _sigfault.si_addr = 0x489:0x489 正是 1161 的十六进制。siginfo_t 中 _kill 与 _sigfault 是同一块内存按 union 分支读取的别名,这里从 si_addr 读出来的其实是 si_pid 的值。SI_USER 场景下不要把 si_addr 当作出错地址。
第二步:理解信号为什么落在等待线程上
signal(7) 对进程导向信号的递送规则:信号会递送给任意一个未阻塞该信号的线程;多个线程都未阻塞时,内核任选一个。kill(getpid(), sig) 发出的正是进程导向信号,于是:
core 里记录的"崩溃线程"与真正肇事的线程没有任何关系,崩在哪个线程是随机的。
第三步:全线程 bt 确认没有别的 fault 现场
用 thread apply all bt 导出所有线程栈,按"0 号帧是阻塞等待"过滤,看是否还有线程停在可疑 PC 上:
awk '/^Thread [0-9]+/{t=$0} /#0/{if ($0 !~ /futex|epoll|poll|read|write|nanosleep|clock_nanosleep|accept|msgrcv|recv|__select/) print t}' bt.txt 本例 82 个线程过滤后只剩两个:一个在消息循环的时钟调用里,一个在消息反序列化的内存分配里,都是正常业务。没有任何线程停在错误地址或信号返回帧上——发送 kill 的线程送完信号已经回到等待。这一步把"另有线程踩了坏地址"的可能性排除掉,结论收敛到 kill 自杀。
第四步:反汇编找 kill 的调用方
剩下的问题是"谁调用了 kill"。在本例的进程中,只有一个模块导入了 kill 符号:一个商用闭源实时协议栈库。反汇编定位到它注册的异常处理器,逻辑如下(示意):
exception_handler(int sig)
{
dump_internal_trace(); /* 栈内跟踪转储 */
set_fatal_flag(); /* 置全局 fatal 标志 */
signal(sig, SIG_DFL); /* 恢复信号默认处置 */
kill(getpid(), sig); /* 重发信号,按默认动作终止并生成 core */
} 这是嵌入式实时栈里常见的一种错误处理模式:库内部 fatal 后通过重发信号自杀,让进程按信号默认动作终止并留下 core。副作用是 core 的表面现象(崩溃线程、崩溃位置)与真实原因完全脱节。
库的全局状态对象可以作为佐证:core 里该对象的 fatal 标志已置位、运行状态为正常运行态,说明异常处理器确实执行过,且死亡不发生在初始化或关闭流程中。
触发场景还原
日志时间线还原出根因:上位界面的一次操作让协议栈在 231ms 内被连续 start 两次,中间没有 stop,启动入口也没有防重入保护;协议栈二次初始化后在运行态出 fatal,走到上面那条自杀路径。这类"无防重入保护的重复初始化"是此类崩溃的常见根因,修复方向是给初始化入口加状态防重入(启动中或已启动时对后续请求去重,或保证完整的 stop→start 序列),同时排查触发事件为何被重复投递。
要点
- core 崩在等待函数(futex / cond_wait / poll / read 等)时,先
p $_siginfo再看 bt。 - si_code = SI_USER 且 si_pid = 自身 PID,说明是进程内 kill 自杀;此时崩溃线程的栈没有参考价值。si_code = SEGV_MAPERR / SEGV_ACCERR 才是真实访存错误,崩溃线程的栈这时才值得细看。
- SI_USER 场景下 si_addr 是 si_pid 的 union 别名,不要当作出错地址。
- 自杀信号是进程导向的,任意未阻塞线程都可能接走,崩在哪个线程是随机的。
- 定位调用方:全线程 bt 过滤等待帧排除其他 fault 现场,再从导入符号表找 kill 的引用并反汇编。
- 闭源实时库常采用"恢复默认处置后重发信号"的自杀模式,core 现场与根因脱节是这个模式的特征。